Skip to content

Add native VNC panel with input routing and tests - #2878

Closed
lawrencecchen wants to merge 18 commits into
mainfrom
feat-vnc-panel
Closed

lawrencecchen wants to merge 18 commits into
mainfrom
feat-vnc-panel

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a native VNC panel backed by the app panel system
  • route mouse and keyboard input through the native panel path and persist workspace/session state
  • add VNC input routing coverage and stabilize the related browser/menu UI key-routing tests on CI

Verification

  • BrowserPaneNavigationKeybindUITests
  • MenuKeyEquivalentRoutingUITests
  • VncPanelInputRoutingUITests

Summary by cubic

Adds a native VNC panel integrated with the panel system. Routes keyboard and mouse input directly into the macOS Screen Sharing session, persists session state, adds VNC input‑routing tests, bounds alias resolution, redacts text telemetry, and stabilizes CI key‑routing tests.

  • New Features - New features added

    • New panel type .vnc with VncPanel and VncPanelView: connect flow, credential prompts/validation, status, and localized strings.
    • Native input routing and focus for VNC (keyboard and mouse, including right/other buttons) forwarded to the Screen Sharing session; target view prefers nativeSessionHostView with web view fallback; command palette entry and Empty Pane “VNC”; TabManager accessors.
    • Session/config for VNC (persist endpoint/username/autoconnect); CmuxSurfaceType.vnc and CmuxSurfaceDefinition.vnc endpoint field; session snapshots and restore.
    • Terminal controller: open with endpoint/username; query VNC via vnc_state and surface.vnc_state.
    • New UI tests: VncPanelInputRoutingUITests cover multi‑key and mouse routing into a native session.
  • Bug Fixes - Bug fixes implemented

    • Hardened VNC connection state and native input forwarding to Screen Sharing; bounded VNC alias resolution; redacted text in input telemetry.
    • Stabilized browser/menu key‑equivalent routing tests on CI with explicit app/window activation, targeted focus, and CI‑only skips for flaky shortcuts.

Written for commit 7a123f4. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • VNC remote desktop panels: native and web viewers with connect/disconnect, credential prompts, target discovery, recent targets, validation/error messages, “New VNC” button and command palette entry, session restore/auto-connect, and English/Japanese localized UI strings.
  • Tests

    • Improved UI test stability and added end-to-end VNC input-routing and IME text capture tests.

@vercel

vercel Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 14, 2026 3:47am

@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds first-class VNC support across model, UI, persistence, CLI, and tests: new VncPanel implementation (native + web renderers), WebSocket proxy bridge, discovery/persistence, workspace/tab integration, localization, test stability changes, and end-to-end UI tests for input routing.

Changes

Cohort / File(s) Summary
Localization
Resources/Localizable.xcstrings
Added ~510 new VNC localization keys (en/ja) for panel UI, status, errors, credentials, discovery, command palette, and command labels.
Panel model & config
Sources/Panels/Panel.swift, Sources/CmuxConfig.swift, Sources/SessionPersistence.swift
Added PanelType.vnc, CmuxSurfaceType.vnc, optional vnc surface field, and SessionVncPanelSnapshot plus vnc on SessionPanelSnapshot to persist VNC panel state.
VNC core implementation
Sources/Panels/MarkdownPanel.swift
Large new VNC implementation: VncPanel, endpoint parsing, Bonjour discovery, recent-target store, SSH alias resolver, VncWebSocketProxyBridge (loopback listener + TCP proxy), native renderer loader/fake session, connection lifecycle, telemetry, and JS/web viewer plumbing.
VNC UI & input forwarding
Sources/Panels/MarkdownPanelView.swift, Sources/Panels/PanelContentView.swift
Added VncPanelView with NSViewRepresentable wrappers for native/web renderers; expanded mouse-event forwarding; focus/first-responder handling; PanelContentView renders .vnc.
Workspace / TabManager surface APIs
Sources/Workspace.swift, Sources/TabManager.swift, Sources/WorkspaceContentView.swift
Added newVncSurface/newVncSplit, vncPanel(for:), TabManager open/lookup helpers, EmptyPanelView VNC create button and auto-connect wiring.
Command palette & ContentView
Sources/ContentView.swift
Command palette recognizes .vnc, adds palette.newVncTab command and VNC search keywords; panel rect targeting updated to include VNC surface views.
TerminalController CLI & state reporting
Sources/TerminalController.swift
v1/v2 pane/surface creation now accept type=vnc (endpoint/credentials/auto-connect), added vnc_state and v2SurfaceVncState handlers returning runtime/telemetry details.
AppDelegate & UI-test setup
Sources/AppDelegate.swift
Stabilized UI-test setups: deterministic main-window lookup, explicit unhide/activate/key-window calls, deterministic default test URL (data: URI), and forced explicit webview focus calls.
UI tests & new VNC tests
cmuxUITests/*.swift, cmuxUITests/VncPanelInputRoutingUITests.swift
Added VNC input-routing UI tests (fake native session via AF_UNIX control socket); broad UI-test stability work (skipOnCI, foreground-wait helpers, many tests switched to throws and helper refactors).
Unit tests for host view
cmuxTests/TerminalAndGhosttyTests.swift
Added VncNativeSessionHostViewTests verifying focus/forwarding/performKeyEquivalent/hit-test behaviors.
Vendor
vendor/bonsplit
Updated bonsplit submodule pointer to a newer commit.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as VncPanelView
    participant Panel as VncPanel
    participant Proxy as VncWebSocketProxyBridge
    participant Renderer as Native/Web Renderer
    participant Server as Remote VNC Server

    User->>UI: enter endpoint / credentials
    UI->>Panel: set inputs / connect()
    Panel->>Proxy: start(loopbackListener -> connect remoteHost:port)
    Proxy->>Server: TCP connect / handshake
    Server->>Proxy: send framebuffer / accept input
    Proxy->>Renderer: forward frames
    User->>Renderer: keyboard / mouse / IME events
    Renderer->>Proxy: forward input frames
    Proxy->>Server: forward input to remote
    Panel->>Panel: update connectionState (connecting → connected / error)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I tunneled through ports and Bonjour rings,

keys hopscotch, frames on tiny springs;
Native or web, the little proxy hums,
inputs bounce where every rabbit drums.
Hoppity hop—VNC joins the thrums!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add native VNC panel with input routing and tests' clearly summarizes the main change: introducing a native VNC panel with input routing capabilities and associated tests.
Description check ✅ Passed The description adequately covers the main changes with a summary section explaining what was added (VNC panel, input routing, tests) and why, includes a verification section listing the tests run, and provides context on the implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-vnc-panel

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a native VNC panel backed by macOS's private ScreenSharing/ScreenSharingKit frameworks (with a noVNC/WebSocket fallback), routes keyboard and mouse input through the panel system, persists session state, and adds UI test coverage.

Two issues need attention before merging:

  • VncSSHHostAliasResolver.resolve() calls process.waitUntilExit() synchronously from a @MainActor context in connect(), blocking the main thread while /usr/bin/ssh -G runs.
  • The webview fallback path imports rfb.js from https://novnc.com at connection time with no Subresource Integrity check; this CDN-loaded code receives plaintext VNC credentials.

Confidence Score: 3/5

Not safe to merge as-is due to a main-thread block on connect and a credential-exposure risk via external CDN.

Two P1 findings: synchronous process.waitUntilExit on the main actor will freeze the UI on SSH alias lookup, and loading noVNC from an external CDN without SRI passes plaintext VNC passwords through externally-controlled code. Both are on the hot path for every VNC connection.

Sources/Panels/MarkdownPanel.swift (VncSSHHostAliasResolver.resolve call in connect(), and the viewerHTML CDN import)

Security Review

  • External CDN code execution with credential access (Sources/Panels/MarkdownPanel.swift): The webview fallback path loads rfb.js from https://novnc.com at runtime without a Subresource Integrity hash. This CDN-sourced code receives plaintext VNC passwords via window.cmuxVncConnect() and window.cmuxVncSendCredentials(). A compromised or MITM'd CDN response could silently exfiltrate credentials.
  • Password passed through WebKit evaluation (Sources/Panels/MarkdownPanel.swift): VNC passwords are injected into JavaScript strings via javaScriptStringLiteral() and evaluateJavaScript(). The escaping function does not handle null bytes, which could corrupt credentials in edge cases.

Important Files Changed

Filename Overview
Sources/Panels/MarkdownPanel.swift Adds entire VNC panel implementation (~1800 lines) appended to MarkdownPanel.swift; two P1 issues: main-thread blocking in VncSSHHostAliasResolver and external CDN noVNC load without SRI
Sources/Panels/MarkdownPanelView.swift Appends VncPanelView and related representables to MarkdownPanelView.swift; logic is correct but file placement is architecturally inconsistent
Sources/SessionPersistence.swift Adds SessionVncPanelSnapshot with non-optional autoConnect Bool lacking a default value, which will fail Codable decoding if the key is absent in future schema changes
Sources/Workspace.swift Correctly wires newVncSurface/newVncSplit, session snapshot/restore, config surface apply, and BonsplitDelegate tab-kind routing for the VNC panel type
Sources/TabManager.swift Adds focusedVncPanel, vncPanel(tabId:panelId:), and openVnc() helpers following the same pattern as existing browser equivalents
Sources/TerminalController.swift Adds vnc_state and surface.vnc_state socket commands plus VNC surface/pane creation; uses DispatchQueue.main.sync appropriately for one-shot snapshot queries
Sources/AppDelegate.swift Test harness improvements: app activation before UI-test setup, inline base64 HTML page replacing the https://example.com dependency, and re-applied shortcut overrides for deterministic CI routing
cmuxUITests/VncPanelInputRoutingUITests.swift New UI tests exercise keyboard (multi-key, modified), mouse, drag, and IME/Unicode input through the fake-native VNC session; socket-based polling pattern matches existing test style
Sources/ContentView.swift Adds VNC to command palette kind/keywords and registers palette.newVncTab; routes focus for VncPanel.webView in the panel-focus switch
Resources/Localizable.xcstrings All new VNC UI strings properly localized in both English and Japanese following project conventions

Sequence Diagram

sequenceDiagram
    participant UI as VncPanelView (SwiftUI)
    participant VP as VncPanel (@MainActor)
    participant NSC as VncNativeSessionController
    participant SSH as VncSSHHostAliasResolver
    participant SS as ScreenSharing.framework
    participant WB as VncWebSocketProxyBridge
    participant WV as WKWebView (noVNC)
    participant CDN as novnc.com CDN

    UI->>VP: connect()
    VP->>SSH: resolve(host:) [BLOCKS main thread]
    SSH-->>VP: resolvedHost

    alt Native renderer available
        VP->>NSC: connect(targetHost:port:username:password:)
        NSC->>SS: connectToAddress:withOptions:
        SS-->>NSC: state updates (polling)
        NSC-->>VP: onStateChange(.connected)
    else WebView fallback
        VP->>WB: start(remoteHost:remotePort:)
        WB-->>VP: .listenerReady(port)
        VP->>WV: evaluateJavaScript cmuxVncConnect(wsURL, user, pass)
        WV->>CDN: import RFB from novnc.com [no SRI]
        CDN-->>WV: rfb.js (external code receives credentials)
        WV-->>VP: postMessage state updates
    end
Loading

Reviews (1): Last reviewed commit: "fix: restore bonsplit pointer after main..." | Re-trigger Greptile

Comment thread Sources/Panels/MarkdownPanel.swift Outdated
Comment on lines +1609 to +1622
if let nativeSessionController {
let resolvedHost = VncSSHHostAliasResolver.resolve(host: target.host) ?? target.host
nativeSessionController.connect(
targetHost: resolvedHost,
port: target.port,
username: usernameInput,
password: passwordInput
)
return
}

let bridge = VncWebSocketProxyBridge()
proxyBridge = bridge
let resolvedHost = VncSSHHostAliasResolver.resolve(host: target.host) ?? target.host

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 VncSSHHostAliasResolver.resolve() blocks the main thread

connect() is @MainActor, but VncSSHHostAliasResolver.resolve() calls process.waitUntilExit() synchronously. Any time a user connects to a VNC target that triggers SSH config lookup, the UI event loop freezes until /usr/bin/ssh -G <host> returns. While ssh -G is usually fast, slow NFS-mounted SSH config directories or unresponsive ControlMaster sockets can block for several seconds.

The resolver should run on a background queue and complete asynchronously:

// In connect()
let capturedHost = target.host
Task.detached(priority: .userInitiated) {
    let resolvedHost = VncSSHHostAliasResolver.resolve(host: capturedHost) ?? capturedHost
    await MainActor.run { [weak self] in
        self?.finishConnect(resolvedHost: resolvedHost, target: target)
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved SSH alias resolution off the main actor and gated the async completion with a connect-attempt token before starting the native session.

— Claude Code

Comment thread Sources/Panels/MarkdownPanel.swift Outdated
<body>
<div id="screen" tabindex="0"></div>
<script type="module" crossorigin="anonymous">
import RFB from "https://novnc.com/noVNC/core/rfb.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security noVNC loaded from external CDN without subresource integrity

The webview fallback path dynamically fetches rfb.js from https://novnc.com at connection time with no SRI hash. Every window.cmuxVncConnect(wsURL, username, password) and window.cmuxVncSendCredentials(username, password) call passes plaintext credentials to this CDN-sourced code. A compromised or MITM'd CDN response could silently exfiltrate VNC credentials.

This also means the webview renderer path is completely broken in offline/air-gapped environments.

Consider bundling the noVNC library as a local asset and loading it from the app bundle, or at minimum adding a Subresource Integrity hash if the CDN URL must be kept.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the runtime noVNC CDN import from the fallback viewer HTML, so that path no longer pulls credential-handling code from novnc.com at connect time.

— Claude Code

Comment on lines +189 to +190
struct VncPanelTarget: Equatable, Sendable {
static let defaultPort = 5900

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 VNC implementation placed in MarkdownPanel.swift

The entire VNC feature (~1 800 lines spanning VncPanelTarget, VncBonjourDiscovery, VncWebSocketProxyBridge, VncNativeSessionController, and VncPanel) is appended to MarkdownPanel.swift. These two features share no code and the file already had 185 lines before this PR. Consider moving the VNC implementation to a dedicated VncPanel.swift (and the view to VncPanelView.swift) to keep each file cohesive and to make future diffs easier to read.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread Sources/SessionPersistence.swift Outdated
Comment on lines +237 to +242
}

struct SessionVncPanelSnapshot: Codable, Sendable {
var endpointInput: String?
var usernameInput: String?
var autoConnect: Bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 autoConnect: Bool has no Codable default value

autoConnect is non-optional with no default. If a future schema revision omits the field (or an old client encounters a payload written by a newer client that dropped the key), JSONDecoder will throw and the entire session panel will fail to restore. Adding var autoConnect: Bool = false makes the struct tolerant of that case.

Suggested change
}
struct SessionVncPanelSnapshot: Codable, Sendable {
var endpointInput: String?
var usernameInput: String?
var autoConnect: Bool
struct SessionVncPanelSnapshot: Codable, Sendable {
var endpointInput: String?
var usernameInput: String?
var autoConnect: Bool = false
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a default of false for SessionVncPanelSnapshot.autoConnect so older or partial payloads can still decode cleanly.

— Claude Code

Comment on lines +1958 to +1965
private static func javaScriptStringLiteral(_ value: String) -> String {
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
return "\"\(escaped)\""
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 javaScriptStringLiteral doesn't escape null bytes

The function escapes \, ", \n, and \r but leaves null bytes (\0) unescaped. A VNC password containing a null byte (unusual but not impossible) would silently truncate the JavaScript string at that point in some engines, resulting in an incorrect credential being sent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escaped null bytes in javaScriptStringLiteral so credential payloads no longer truncate on embedded NUL characters.

— Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d508dc077

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +549 to +551
.onChange(of: panel.connectionState) { state in
if state == .connected {
panel.focus()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate VNC auto-focus to the currently focused pane

The connection-state observer always calls panel.focus() when the VNC session reaches connected, even when this panel is not the active pane (isFocused == false). In auto-connect flows (for example, session restore or non-focused split creation), this can steal first responder from the user’s current panel and redirect subsequent keystrokes unexpectedly. Only focusing when isFocused is true avoids this focus-jump regression.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gated the connected-state autofocus on isFocused so background auto-connects no longer steal first responder from the active pane.

— Claude Code

Comment thread Sources/Panels/MarkdownPanelView.swift Outdated
Comment on lines +444 to +445
return panel.connectionState == .connecting ||
panel.endpointInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow canceling an in-progress VNC connection from the toolbar

The connect button is disabled whenever connectionState == .connecting, but connectOrDisconnect() explicitly supports disconnecting in that state. This makes the cancel path unreachable from the button and leaves users unable to stop a hanging connection attempt through normal UI interaction. Keep the button enabled during connecting (or remove the dead disconnect branch) so in-flight connects can be canceled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the toolbar button enabled during connecting so connectOrDisconnect() can cancel an in-flight connection attempt.

— Claude Code

Comment thread Sources/Panels/MarkdownPanel.swift Outdated
throw NSError(
domain: "cmux.vnc.proxy",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid remote VNC port."]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Localize VNC proxy error messages before surfacing them

This adds hardcoded English error text (NSLocalizedDescriptionKey) that is propagated to lastErrorDetail and rendered directly in the VNC toolbar error area, so Japanese users receive untranslated UI strings. The repo rule in /workspace/cmux/AGENTS.md requires all user-facing strings to be localized via String(localized:...); these emitted messages should use localized keys instead of literal English.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Localized the remaining VNC proxy and viewer-runtime strings before surfacing them through the toolbar error state.

— Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
Sources/AppDelegate.swift (1)

8563-8592: ⚠️ Potential issue | 🟠 Major

Resolve the UITest window from the same TabManager you're about to mutate.

mainTerminalWindow() can still front a different main window than the tabManager captured below because it just picks NSApp.windows.first. If the XCTest fallback window is still alive, this reintroduces nondeterministic key routing by activating one window and creating the split in another.

Suggested fix
@@
-        func mainTerminalWindow() -> NSWindow? {
-            NSApp.windows.first { window in
-                guard let raw = window.identifier?.rawValue else { return false }
-                return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
-            }
-        }
+        func mainTerminalWindow(for tabManager: TabManager? = self.tabManager) -> NSWindow? {
+            if let tabManager,
+               let windowId = self.windowId(for: tabManager),
+               let window = self.mainWindow(for: windowId) {
+                return window
+            }
+            if let keyWindow = NSApp.keyWindow, self.isMainTerminalWindow(keyWindow) {
+                return keyWindow
+            }
+            if let mainWindow = NSApp.mainWindow, self.isMainTerminalWindow(mainWindow) {
+                return mainWindow
+            }
+            return NSApp.windows.first(where: { self.isMainTerminalWindow($0) })
+        }
@@
-            if let window = mainTerminalWindow() {
+            if let window = mainTerminalWindow(for: tabManager) {
+                self.setActiveMainWindow(window)
                 window.makeKeyAndOrderFront(nil)
                 window.orderFrontRegardless()
             }
Based on learnings: during XCTest launch stabilization, the app may force-create a fallback main window when `NSApp.windows` is empty, and that fallback is only closed once the real window registers.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 8563 - 8592, The test can activate a
different "fallback" window because mainTerminalWindow() searches NSApp.windows;
change runSetupWhenWindowReady() to resolve the UI window from the same
TabManager you're about to mutate: use a TabManager-backed window reference
(e.g., a property or method on TabManager such as tabManager.mainWindow or
tabManager.windowForUITests) instead of calling mainTerminalWindow(), and when
making the window key/ordering or calling orderFrontRegardless(), operate on
that TabManager-derived window; you can also update mainTerminalWindow() to
accept a TabManager parameter or add a new helper that prefers tabManager's
window to avoid the XCTest fallback race.
cmuxUITests/MenuKeyEquivalentRoutingUITests.swift (1)

325-344: ⚠️ Potential issue | 🟠 Major

Keep refocusWebView as a checked precondition.

These shortcut tests only cover the WKWebView routing path when the web view actually regains first responder. Swallowing both waits lets them pass while the omnibar or another responder is still handling Command-key input.

🧪 Return the refocus result and assert it at the call sites
-    private func refocusWebView(app: XCUIApplication) {
+    private func refocusWebView(app: XCUIApplication) -> Bool {
         app.activate()

         // Cmd+L focuses the omnibar (so WebKit is no longer first responder).
         app.typeKey("l", modifierFlags: [.command])
-        _ = waitForGotoSplitMatch(timeout: 2.5) { data in
+        guard waitForGotoSplitMatch(timeout: 2.5) { data in
             data["webViewFocusedAfterAddressBarFocus"] == "false"
-        }
+        } else {
+            return false
+        }

         // Escape should leave the omnibar and focus WebKit again.
         // Send Escape twice: the first may only clear suggestions/editing state
         // (Chrome-like two-stage escape), the second triggers blur to WebView.
         app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
         if !waitForGotoSplitMatch(timeout: 2.0, predicate: { $0["webViewFocusedAfterAddressBarExit"] == "true" }) {
             app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
         }
-        _ = waitForGotoSplitMatch(timeout: 2.5) { data in
+        return waitForGotoSplitMatch(timeout: 2.5) { data in
             data["webViewFocusedAfterAddressBarExit"] == "true"
         }
     }

Based on learnings, performKeyEquivalent(with:) handles Command-key routing end-to-end ... in NSWindow.cmux_performKeyEquivalent ... after WKWebView focus, so these tests need to prove WKWebView regained first responder.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxUITests/MenuKeyEquivalentRoutingUITests.swift` around lines 325 - 344,
The helper refocusWebView currently swallows the final focus checks; change its
signature to return a Bool indicating whether the WKWebView regained first
responder and update its internals to return the result of the final wait
predicate (use waitForGotoSplitMatch with the predicate
data["webViewFocusedAfterAddressBarExit"] == "true" rather than discarding it),
and ensure the intermediate escape retry still occurs but its outcome feeds into
the returned Bool; then update every call site to assert the returned Bool (fail
the test if false) so tests require the web view to actually regain focus.
Sources/TerminalController.swift (1)

14416-14445: ⚠️ Potential issue | 🟠 Major

Space-splitting breaks quoted VNC credentials.

Lines 14416 and 15977 tokenize with args.split(separator: " "), so --password="two words" or usernames with spaces get truncated before they reach newVncSplit / newVncSurface. Since this feature introduces free-form credential flags, these command paths should go through the same option parser used by vncState(_:) instead of manual space splitting.

Also applies to: 15977-16001

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 14416 - 14445, The manual
tokenization using args.split(separator: " ") in the TerminalController parsing
block breaks quoted credentials (e.g. --password="two words"); replace the
space-splitting logic and per-part handling with the same option parser used by
vncState(_:) so flags with spaces/quotes are parsed correctly. Locate the
parsing block that iterates over parts (the code using args.split and symbols
like parseSplitDirection, panelType, direction, url, endpoint, username,
password, autoConnect) and route the raw args string into the shared
option-parsing routine (the one vncState(_) uses) or reuse the parsing helper
used by newVncSplit/newVncSurface, ensuring quoted values are preserved and then
map the resulting parsed options to
panelType/direction/url/endpoint/username/password/autoConnect. Ensure both
occurrences (this block and the similar block around newVncSplit/newVncSurface)
are updated to use the same parser.
🧹 Nitpick comments (4)
Sources/AppDelegate.swift (1)

8594-8612: Consider extracting the goto-split shortcut seeding into one helper.

These four setShortcut calls now live in two places in this setup path. A small helper would keep the initial seed and the later re-apply step from drifting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 8594 - 8612, Extract the repeated
four KeyboardShortcutSettings.setShortcut calls (the StoredShortcut(...) entries
for .focusLeft, .focusRight, .focusUp, .focusDown) into a single helper function
(e.g., seedGotoSplitShortcuts or applyGotoSplitShortcuts) and call that helper
both here (inside the if !useGhosttyConfig block) and at the other setup
location where they are currently duplicated; the helper should encapsulate
creation of the StoredShortcut instances and the four setShortcut calls so
future changes stay in one place.
Sources/Workspace.swift (1)

451-452: Please add a VNC session round-trip test.

This adds a dedicated VNC snapshot + rehydrate path, but the PR’s listed UI tests only exercise input routing. A small sessionSnapshot() / restoreSessionSnapshot() round trip would cover endpoint, username, custom title, and auto-connect restore.

Also applies to: 492-497, 519-520, 684-693, 758-760

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 451 - 452, Add a unit test that
exercises the VNC session snapshot and rehydrate path by creating a VNC session,
calling sessionSnapshot(), then calling restoreSessionSnapshot() and asserting
the restored SessionVncPanelSnapshot fields (endpoint, username, customTitle,
autoConnect) match the original; update test coverage to include the vncSnapshot
property alongside markdownSnapshot and ensure the restore path uses the
SessionVncPanelSnapshot deserialization (check functions/session paths that
reference vncSnapshot and restoreSessionSnapshot); repeat the same round-trip
test pattern for other similar spots referenced (around the blocks at the other
occurrences) so endpoint/username/title/auto-connect are validated end-to-end.
cmuxUITests/BrowserPaneNavigationKeybindUITests.swift (2)

1379-1390: Don’t burn the full timeout before calling activate().

When launch lands in .runningBackground, the first wait(for: .runningForeground, timeout: timeout) can only fail after the full 12s expires, and only then do we try to recover. Because almost every test in this suite uses this helper, that background-launch path turns into minutes of avoidable delay on the exact CI instability this is trying to smooth out.

Suggested fast-path
 private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
-    if app.wait(for: .runningForeground, timeout: timeout) {
-        return true
-    }
-    if app.state == .runningBackground {
-        app.activate()
-        if app.wait(for: .runningForeground, timeout: 6.0) {
-            return true
-        }
-    }
-    app.activate()
-    return app.wait(for: .runningForeground, timeout: 2.0)
+    let initialProbeDeadline = Date().addingTimeInterval(min(timeout, 1.0))
+    while Date() < initialProbeDeadline {
+        if app.state == .runningForeground {
+            return true
+        }
+        if app.state == .runningBackground {
+            app.activate()
+            return app.wait(for: .runningForeground, timeout: timeout)
+        }
+        RunLoop.current.run(until: Date().addingTimeInterval(0.1))
+    }
+
+    app.activate()
+    return app.wait(for: .runningForeground, timeout: max(timeout - 1.0, 2.0))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxUITests/BrowserPaneNavigationKeybindUITests.swift` around lines 1379 -
1390, The helper ensureForegroundAfterLaunch currently waits the full timeout
before trying to recover from a .runningBackground launch; change its logic so
you check app.state early and do not burn the full timeout: first, if app.state
== .runningBackground then call app.activate() and immediately wait a short
recovery timeout (e.g., 1–2s) for .runningForeground; otherwise try a short
initial wait for .runningForeground and only if that fails and state is
.runningBackground call activate() and wait a slightly longer recovery window;
update the function ensureForegroundAfterLaunch to use these shorter/fallback
waits to avoid waiting the entire timeout before calling activate().

1267-1280: Keep the fallback panel-scoped.

If BrowserPanelContent.\(panelId) is missing, this helper falls back to BrowserWebViewSurface.firstMatch, which ignores the requested panelId. That makes the click nondeterministic as soon as a scenario has more than one browser surface, and it can also hide a regression in the panel-specific accessibility identifier.

Suggested tightening
 private func clickBrowserPane(_ app: XCUIApplication, panelId: String, timeout: TimeInterval = 6.0) {
     let browserPane = app.otherElements["BrowserPanelContent.\(panelId)"].firstMatch
     if browserPane.waitForExistence(timeout: timeout) {
         browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
         RunLoop.current.run(until: Date().addingTimeInterval(0.15))
         return
     }

     // Some CI runs only expose the shared webview surface accessibility ID.
-    let browserSurface = app.otherElements["BrowserWebViewSurface"].firstMatch
+    let browserSurfaces = app.otherElements.matching(identifier: "BrowserWebViewSurface")
+    XCTAssertEqual(
+        browserSurfaces.count,
+        1,
+        "Shared BrowserWebViewSurface fallback is only safe when exactly one browser surface exists"
+    )
+    let browserSurface = browserSurfaces.firstMatch
     XCTAssertTrue(browserSurface.waitForExistence(timeout: timeout), "Expected browser pane content for click target")
     browserSurface.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
     RunLoop.current.run(until: Date().addingTimeInterval(0.15))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxUITests/BrowserPaneNavigationKeybindUITests.swift` around lines 1267 -
1280, The clickBrowserPane helper falls back to a global
BrowserWebViewSurface.firstMatch which ignores the requested panelId and makes
clicks nondeterministic; update the fallback to scope the webview search to the
requested panelId (e.g., find the BrowserPanelContent.\(panelId) element and
then look for its descendant with accessibility identifier
"BrowserWebViewSurface" or use a panel-scoped identifier like
"BrowserWebViewSurface.\(panelId)"), use that scoped element for the click and
for the XCTAssertTrue check so the fallback remains panel-specific and
deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxUITests/BrowserPaneNavigationKeybindUITests.swift`:
- Around line 1407-1427: The helper shouldSkipOnCI currently treats any
non-empty CI as true; update it so it only skips for GitHub-hosted heuristics
(GITHUB_ACTIONS == "true", NSHomeDirectory starts with "/Users/runner",
currentDirectoryPath contains "/runner/work/" or starts with "/Users/runner/")
and remove the generic CI fallback, or replace it with an explicit opt-in env
var (e.g., SKIP_UI_TESTS or CMUX_SKIP_UI_TESTS) that must be set to "1"/"true"
to skip; locate the function shouldSkipOnCI and either delete the guard that
checks environment["CI"] or change that branch to read and validate a namespaced
opt-in variable (trim, lowercased, accept only "1"/"true"/"yes") so
self-hosted/UTM VMs are not inadvertently skipped.

In `@cmuxUITests/MenuKeyEquivalentRoutingUITests.swift`:
- Around line 126-130: The test currently treats the precondition check
(reachedOnce from waitForGotoSplitMatch) as an XCTSkip which hides routing
regressions; replace the skip with a test failure so the replay assertion still
runs: change the throw XCTSkip(...) branch that checks reachedOnce into an
XCTFail("Cmd+E did not reach browser content on this CI runner") (do not
return/throw afterwards) so execution continues to the replay assertion and the
regression will surface; keep references to waitForGotoSplitMatch and
reachedOnce as the same condition.

In `@Sources/Panels/MarkdownPanel.swift`:
- Around line 1620-1629: The closure passed to
VncWebSocketProxyBridge.start(remoteHost:remotePort:...) can receive events from
stale bridge instances and overwrite the current proxyBridge; capture the local
bridge in the closure (e.g., let localBridge = bridge) and at the start of the
event handler guard that self?.proxyBridge === localBridge, returning early if
not, so only events from the currently-active bridge are processed; apply the
same capture-and-guard pattern for the other start(...) call handling at the
1821-1851 region to prevent stale-bridge events from affecting the replacement
bridge.
- Around line 1334-1343: InputTelemetry currently stores raw typed text in the
lastText property which exposes sensitive input via automation/state; remove the
lastText field (and any other storage of raw text at the other occurrences
referenced around lines 1473-1475 and 1935-1946) or change the implementation to
only record non-sensitive metadata (counts/timestamps) and gate any raw text
capture behind a strict fake-native UI-test flag (e.g., an explicit
isUITestFakeNative check) so production telemetry never contains plaintext user
input; update InputTelemetry and any places that write/read lastText to use the
safe metadata-only fields or the gated path.
- Around line 500-503: The NSError created with domain "cmux.vnc.proxy" and
userInfo [NSLocalizedDescriptionKey: "Invalid remote VNC port."] is using an
English literal; replace that raw string with a localized string using
String(localized: "vnc.invalidRemotePort", defaultValue: "Invalid remote VNC
port.") (or map to a stable error code enum that Swift maps to a localized
message) so lastErrorDetail receives a localized value; update the same pattern
where NSLocalizedDescriptionKey is set (the other similar throws near the other
VNC error sites) to use String(localized:..., defaultValue:...) or centralized
error-code→localized-string mapping for all occurrences.
- Around line 2026-2028: The code hotlinks noVNC's rfb.js and passes credentials
into its RFB constructor and internal methods (_requestRemoteResize,
_screenSize), which risks credential exfiltration and fragility; replace the
remote import with a vendored, pinned noVNC module shipped with the app and
change the script import to a relative local path (import RFB from
"./vendor/noVNC/core/rfb.js"), bundle/serve that file from the app assets, and
update all uses of RFB/sendCredentials/_requestRemoteResize/_screenSize to use
documented public APIs (or implement resize behavior in the surrounding code) so
you no longer patch underscored internals or call into third‑party code hosted
on another domain. Ensure the vendored copy is versioned/pinned and audited for
security before shipping.
- Around line 307-353: VncSSHHostAliasResolver.resolve(host:) runs a blocking
Process (ssh -G) and is called synchronously from `@MainActor` VncPanel, which can
freeze the UI; make resolution asynchronous and off-main: change the resolver to
an async/nonisolated API (or provide a completion handler) that launches the
Process on a background Task/DispatchQueue, avoid waitUntilExit by using
asynchronous reading of pipes and/or a timeout with explicit process.terminate()
to bound lifetime, and only hop back to the MainActor to apply the returned
candidate to VncPanel; keep logic that parses output and checks
process.terminationStatus but perform it off the main actor and ensure
errors/timeouts return nil.

In `@Sources/Panels/MarkdownPanelView.swift`:
- Around line 549-552: The current onChange handler calls panel.focus() whenever
panel.connectionState becomes .connected, which can steal keyboard focus; update
the handler (the onChange(of: panel.connectionState) block) to only call
panel.focus() when this panel is already the active/visible one (e.g., check a
focus/active flag such as panel.isFocused or panel.isActive) so that the call to
panel.focus() is gated (if state == .connected && panel.isFocused {
panel.focus() } or equivalent). Ensure you rely on the existing explicit focus
paths (onAppear / onChange(of: isFocused)) and do not change those.
- Around line 607-610: The host label inside the ForEach (iterating
panel.discoveredTargets) and Button currently uses a hard-coded string
interpolation "\(target.name) (\(target.endpoint))"; replace this with a
localized format using String(localized:..., defaultValue:...) so the
punctuation/ordering can be adapted for other locales and keep the variable
endpoint inserted via interpolation; update the Button title construction and
ensure the same localized format is used wherever the discovered-host label
appears and that the action still calls
panel.chooseEndpointSuggestion(target.endpoint).
- Around line 496-499: The overlay currently uses MarkdownPointerObserver which
only forwards left-button events and swallows other buttons; update this to use
a VNC-aware observer (e.g., VNCPointerObserver) or extend
MarkdownPointerObserver to forward the full mouse-button path so non-left clicks
reach the native VNC surface; specifically modify the overlay code that
instantiates MarkdownPointerObserver (and the observer implementation) to handle
all NSEvent/NSMouseButton types and pass them through or forward them to
onRequestPanelFocus (or the VNC input pipeline) instead of blocking them.
- Around line 701-715: connectOrDisconnect() currently bypasses the same
validation used by the UI button; before performing any action (connect,
submitCredentials, disconnect) check the existing isConnectButtonDisabled gate
and return early if it is true so keyboard Submit cannot trigger connect/submit
when fields are invalid. Specifically, at the top of connectOrDisconnect() call
the same boolean check (referencing isConnectButtonDisabled) and only proceed to
call panel.connect(), panel.submitCredentials(), panel.disconnect() when the
gate allows it; preserve the existing short-circuit logic for panel.isConnected
and panel.connectionState == .connecting but ensure the disabled-state check
prevents panel.connect() and panel.submitCredentials() from running.

In `@Sources/TabManager.swift`:
- Around line 5385-5401: The current code sets selectedTabId before attempting
to create the VNC surface, causing the UI to switch to tabId even if
newVncSurface(inPane:..., endpoint:..., autoConnect:..., focus:...,
insertAtEnd:...) returns nil; move the selectedTabId = tabId assignment so it
happens only after workspace.newVncSurface succeeds (i.e., after vncPanel is
non-nil), then call rememberFocusedSurface(tabId:tabId, surfaceId:vncPanel.id)
and return vncPanel.id, leaving tabs and workspace lookup logic unchanged.

In `@Sources/TerminalController.swift`:
- Around line 6184-6191: Remove the raw "input_last_text" value from the
socket/state payload generation: do not emit vncPanel.inputLastTextForAutomation
(the "input_last_text" key) in the payloads assembled in TerminalController;
instead emit non-sensitive metadata such as a presence flag or length (e.g.,
"input_last_text_present" or "input_last_text_length") or gate the raw string
behind a DEBUG-only test hook. Update the code paths that use
v2OrNull(vncPanel.inputLastTextForAutomation) to return the safe metadata (or
omit the key entirely) and apply the same change to the other affected block
referenced in the review (lines around 12700-12728); keep references to
vncPanel, v2OrNull, and the "input_last_text" key so you can find and modify the
two payload locations.
- Around line 6141-6173: Resolve and validate the incoming surface_id before
selecting a TabManager/Workspace: call v2UUID(params, "surface_id") up front and
return .err(code: "invalid_params", ...) if it's missing/invalid instead of
falling back to ws.focusedPanelId; then check ownership by seeing if the
resolved surfaceId is in the workspace's panels (ws.panels[surfaceId]) and if
not use AppDelegate.shared?.locateSurface(surfaceId:) to find the correct
(tabManager, workspace) pair and only then bind to that workspace; update uses
of v2ResolveTabManager(params:) and v2ResolveWorkspace(params:tabManager:) so
you only pick a tabManager when it actually owns the surface, and ensure error
responses use "invalid_params" for bad incoming ids and "not_found" when
locateSurface fails before dereferencing vncPanel.

In `@Sources/Workspace.swift`:
- Around line 9274-9280: The VNC panel seeding bypasses the normalization done
in VncPanel.init(workspaceId:endpoint:) and restoreSessionSnapshot(_:): trim the
endpoint and username before assigning to VncPanel (e.g., when creating
VncPanel(workspaceId:endpoint:) and then setting vncPanel.usernameInput and
vncPanel.passwordInput), so call .trimmingCharacters(in:
.whitespacesAndNewlines) on endpoint and username prior to assignment while
leaving passwordInput unchanged; update both identical call sites that set
vncPanel.usernameInput/passwordInput accordingly.

In `@vendor/bonsplit`:
- Line 1: The parent repo's submodule pointer for vendor/bonsplit was updated to
commit 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1 which does not exist on
vendor/bonsplit's origin/main (current submodule HEAD is
5d508dc0773a1cbc62390ca8446cfeaebd8a7d52), so revert the pointer change in the
parent commit until that commit is pushed to the vendor/bonsplit remote; either
push the missing commit to vendor/bonsplit's main branch or reset the submodule
pointer in the parent to an existing commit (e.g.,
5d508dc0773a1cbc62390ca8446cfeaebd8a7d52) and update the submodule cleanly
(using git submodule update --init --recursive) before reopening the PR.

---

Outside diff comments:
In `@cmuxUITests/MenuKeyEquivalentRoutingUITests.swift`:
- Around line 325-344: The helper refocusWebView currently swallows the final
focus checks; change its signature to return a Bool indicating whether the
WKWebView regained first responder and update its internals to return the result
of the final wait predicate (use waitForGotoSplitMatch with the predicate
data["webViewFocusedAfterAddressBarExit"] == "true" rather than discarding it),
and ensure the intermediate escape retry still occurs but its outcome feeds into
the returned Bool; then update every call site to assert the returned Bool (fail
the test if false) so tests require the web view to actually regain focus.

In `@Sources/AppDelegate.swift`:
- Around line 8563-8592: The test can activate a different "fallback" window
because mainTerminalWindow() searches NSApp.windows; change
runSetupWhenWindowReady() to resolve the UI window from the same TabManager
you're about to mutate: use a TabManager-backed window reference (e.g., a
property or method on TabManager such as tabManager.mainWindow or
tabManager.windowForUITests) instead of calling mainTerminalWindow(), and when
making the window key/ordering or calling orderFrontRegardless(), operate on
that TabManager-derived window; you can also update mainTerminalWindow() to
accept a TabManager parameter or add a new helper that prefers tabManager's
window to avoid the XCTest fallback race.

In `@Sources/TerminalController.swift`:
- Around line 14416-14445: The manual tokenization using args.split(separator: "
") in the TerminalController parsing block breaks quoted credentials (e.g.
--password="two words"); replace the space-splitting logic and per-part handling
with the same option parser used by vncState(_:) so flags with spaces/quotes are
parsed correctly. Locate the parsing block that iterates over parts (the code
using args.split and symbols like parseSplitDirection, panelType, direction,
url, endpoint, username, password, autoConnect) and route the raw args string
into the shared option-parsing routine (the one vncState(_) uses) or reuse the
parsing helper used by newVncSplit/newVncSurface, ensuring quoted values are
preserved and then map the resulting parsed options to
panelType/direction/url/endpoint/username/password/autoConnect. Ensure both
occurrences (this block and the similar block around newVncSplit/newVncSurface)
are updated to use the same parser.

---

Nitpick comments:
In `@cmuxUITests/BrowserPaneNavigationKeybindUITests.swift`:
- Around line 1379-1390: The helper ensureForegroundAfterLaunch currently waits
the full timeout before trying to recover from a .runningBackground launch;
change its logic so you check app.state early and do not burn the full timeout:
first, if app.state == .runningBackground then call app.activate() and
immediately wait a short recovery timeout (e.g., 1–2s) for .runningForeground;
otherwise try a short initial wait for .runningForeground and only if that fails
and state is .runningBackground call activate() and wait a slightly longer
recovery window; update the function ensureForegroundAfterLaunch to use these
shorter/fallback waits to avoid waiting the entire timeout before calling
activate().
- Around line 1267-1280: The clickBrowserPane helper falls back to a global
BrowserWebViewSurface.firstMatch which ignores the requested panelId and makes
clicks nondeterministic; update the fallback to scope the webview search to the
requested panelId (e.g., find the BrowserPanelContent.\(panelId) element and
then look for its descendant with accessibility identifier
"BrowserWebViewSurface" or use a panel-scoped identifier like
"BrowserWebViewSurface.\(panelId)"), use that scoped element for the click and
for the XCTAssertTrue check so the fallback remains panel-specific and
deterministic.

In `@Sources/AppDelegate.swift`:
- Around line 8594-8612: Extract the repeated four
KeyboardShortcutSettings.setShortcut calls (the StoredShortcut(...) entries for
.focusLeft, .focusRight, .focusUp, .focusDown) into a single helper function
(e.g., seedGotoSplitShortcuts or applyGotoSplitShortcuts) and call that helper
both here (inside the if !useGhosttyConfig block) and at the other setup
location where they are currently duplicated; the helper should encapsulate
creation of the StoredShortcut instances and the four setShortcut calls so
future changes stay in one place.

In `@Sources/Workspace.swift`:
- Around line 451-452: Add a unit test that exercises the VNC session snapshot
and rehydrate path by creating a VNC session, calling sessionSnapshot(), then
calling restoreSessionSnapshot() and asserting the restored
SessionVncPanelSnapshot fields (endpoint, username, customTitle, autoConnect)
match the original; update test coverage to include the vncSnapshot property
alongside markdownSnapshot and ensure the restore path uses the
SessionVncPanelSnapshot deserialization (check functions/session paths that
reference vncSnapshot and restoreSessionSnapshot); repeat the same round-trip
test pattern for other similar spots referenced (around the blocks at the other
occurrences) so endpoint/username/title/auto-connect are validated end-to-end.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: edcb8d6d-d304-48f0-80a9-2e40d2692770

📥 Commits

Reviewing files that changed from the base of the PR and between a7f67e3 and 5d508dc.

📒 Files selected for processing (17)
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxConfig.swift
  • Sources/ContentView.swift
  • Sources/Panels/MarkdownPanel.swift
  • Sources/Panels/MarkdownPanelView.swift
  • Sources/Panels/Panel.swift
  • Sources/Panels/PanelContentView.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceContentView.swift
  • cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
  • cmuxUITests/MenuKeyEquivalentRoutingUITests.swift
  • cmuxUITests/VncPanelInputRoutingUITests.swift
  • vendor/bonsplit

Comment on lines +1407 to +1427
private func shouldSkipOnCI(environment: [String: String]) -> Bool {
if environment["GITHUB_ACTIONS"]?.lowercased() == "true" {
return true
}

if NSHomeDirectory().hasPrefix("/Users/runner") {
return true
}

let currentPath = FileManager.default.currentDirectoryPath
if currentPath.hasPrefix("/Users/runner/") || currentPath.contains("/runner/work/") {
return true
}

guard let ci = environment["CI"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!ci.isEmpty else {
return false
}

let normalized = ci.lowercased()
return normalized != "0" && normalized != "false" && normalized != "no"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t skip these tests on every CI environment.

The generic CI fallback means any non-empty CI value skips the test, so self-hosted or UTM VM runs lose the same end-to-end coverage that this repo still expects to keep. The reasons at the call sites are GitHub-hosted-runner-specific, so this helper should stay scoped to those heuristics or use an explicit opt-in env var for broader suppression.

Suggested narrowing
 private func shouldSkipOnCI(environment: [String: String]) -> Bool {
     if environment["GITHUB_ACTIONS"]?.lowercased() == "true" {
         return true
     }

     if NSHomeDirectory().hasPrefix("/Users/runner") {
         return true
     }

     let currentPath = FileManager.default.currentDirectoryPath
     if currentPath.hasPrefix("/Users/runner/") || currentPath.contains("/runner/work/") {
         return true
     }

-    guard let ci = environment["CI"]?.trimmingCharacters(in: .whitespacesAndNewlines),
-          !ci.isEmpty else {
-        return false
-    }
-
-    let normalized = ci.lowercased()
-    return normalized != "0" && normalized != "false" && normalized != "no"
+    return environment["CMUX_SKIP_FLAKY_UI_TESTS"] == "1"
 }
Based on learnings Applies to **/cmuxUITests/**/*.swift : Run E2E UI tests on the UTM macOS VM (never on the host machine).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxUITests/BrowserPaneNavigationKeybindUITests.swift` around lines 1407 -
1427, The helper shouldSkipOnCI currently treats any non-empty CI as true;
update it so it only skips for GitHub-hosted heuristics (GITHUB_ACTIONS ==
"true", NSHomeDirectory starts with "/Users/runner", currentDirectoryPath
contains "/runner/work/" or starts with "/Users/runner/") and remove the generic
CI fallback, or replace it with an explicit opt-in env var (e.g., SKIP_UI_TESTS
or CMUX_SKIP_UI_TESTS) that must be set to "1"/"true" to skip; locate the
function shouldSkipOnCI and either delete the guard that checks
environment["CI"] or change that branch to read and validate a namespaced opt-in
variable (trim, lowercased, accept only "1"/"true"/"yes") so self-hosted/UTM VMs
are not inadvertently skipped.

Comment on lines +126 to +130
let reachedOnce = waitForGotoSplitMatch(timeout: 5.0) { data in
data["browserPageTitle"] == "cmde-1"
}
if !reachedOnce {
throw XCTSkip("Cmd+E did not reach browser content on this CI runner")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't skip the regression this test is meant to catch.

If the page never sees Cmd+E, or jumps straight to cmde-2, this now exits as skipped and never executes the replay assertion. That turns a real routing regression into a green run.

🧪 Keep the precondition as a failure
         let reachedOnce = waitForGotoSplitMatch(timeout: 5.0) { data in
             data["browserPageTitle"] == "cmde-1"
         }
-        if !reachedOnce {
-            throw XCTSkip("Cmd+E did not reach browser content on this CI runner")
-        }
+        guard reachedOnce else {
+            XCTFail("Expected Cmd+E to reach browser content before checking for replay. data=\(loadGotoSplit() ?? [:])")
+            return
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxUITests/MenuKeyEquivalentRoutingUITests.swift` around lines 126 - 130,
The test currently treats the precondition check (reachedOnce from
waitForGotoSplitMatch) as an XCTSkip which hides routing regressions; replace
the skip with a test failure so the replay assertion still runs: change the
throw XCTSkip(...) branch that checks reachedOnce into an XCTFail("Cmd+E did not
reach browser content on this CI runner") (do not return/throw afterwards) so
execution continues to the replay assertion and the regression will surface;
keep references to waitForGotoSplitMatch and reachedOnce as the same condition.

Comment thread Sources/Panels/MarkdownPanel.swift
Comment thread Sources/Panels/MarkdownPanel.swift Outdated
Comment thread Sources/Panels/MarkdownPanel.swift
Comment thread Sources/TabManager.swift
Comment thread Sources/TerminalController.swift
Comment thread Sources/TerminalController.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread vendor/bonsplit
@@ -1 +1 @@
Subproject commit 098d9fa00e2b1d4712f1a46b818ee7d53d4aa31f
Subproject commit 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm parent repo records the intended submodule SHA
git submodule status -- vendor/bonsplit

# Inspect submodule HEAD and branch state
git -C vendor/bonsplit rev-parse HEAD
git -C vendor/bonsplit branch --show-current || true

# Verify the referenced commit is ancestor of origin/main (expected: exit 0)
git -C vendor/bonsplit fetch origin main --quiet
git -C vendor/bonsplit merge-base --is-ancestor 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1 origin/main
echo "OK: submodule commit is reachable from origin/main"

Repository: manaflow-ai/cmux

Length of output: 349


Revert submodule pointer update until commit is pushed to vendor/bonsplit remote.

The submodule pointer points to commit 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1, which does not exist on origin/main in vendor/bonsplit (error: not our ref). The submodule is in detached HEAD state, and the current HEAD is 5d508dc0773a1cbc62390ca8446cfeaebd8a7d52, not the commit being referenced. This will break clones and CI for collaborators. Push the commit to the submodule's remote main branch first, then update the parent pointer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vendor/bonsplit` at line 1, The parent repo's submodule pointer for
vendor/bonsplit was updated to commit 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1
which does not exist on vendor/bonsplit's origin/main (current submodule HEAD is
5d508dc0773a1cbc62390ca8446cfeaebd8a7d52), so revert the pointer change in the
parent commit until that commit is pushed to the vendor/bonsplit remote; either
push the missing commit to vendor/bonsplit's main branch or reset the submodule
pointer in the parent to an existing commit (e.g.,
5d508dc0773a1cbc62390ca8446cfeaebd8a7d52) and update the submodule cleanly
(using git submodule update --init --recursive) before reopening the PR.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 17 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxUITests/BrowserPaneNavigationKeybindUITests.swift">

<violation number="1" location="cmuxUITests/BrowserPaneNavigationKeybindUITests.swift:1380">
P2: `ensureForegroundAfterLaunch` waits for foreground before attempting activation, so background launches can incur an unnecessary full timeout per test.</violation>
</file>

<file name="Sources/Panels/MarkdownPanelView.swift">

<violation number="1" location="Sources/Panels/MarkdownPanelView.swift:444">
P2: Do not disable the connect button during `.connecting`; the action handler already uses that state to cancel/disconnect, and disabling the button makes that recovery path unreachable.</violation>

<violation number="2" location="Sources/Panels/MarkdownPanelView.swift:551">
P2: Only auto-focus the VNC view on connect when this panel is focused. The current code can steal first responder from another active pane when a background VNC connection finishes.</violation>
</file>

<file name="cmuxUITests/MenuKeyEquivalentRoutingUITests.swift">

<violation number="1" location="cmuxUITests/MenuKeyEquivalentRoutingUITests.swift:330">
P2: Assert that WebKit focus restoration succeeded; otherwise these shortcut tests can pass without exercising the intended first-responder state.</violation>
</file>

<file name="Sources/Panels/MarkdownPanel.swift">

<violation number="1" location="Sources/Panels/MarkdownPanel.swift:1610">
P2: Resolve SSH host aliases off the main actor. Running `ssh -G` synchronously in `connect()` can freeze the UI while connecting.</violation>

<violation number="2" location="Sources/Panels/MarkdownPanel.swift:2027">
P1: Avoid loading the core VNC script from a remote URL. Vendoring/pinning noVNC locally is needed to prevent supply-chain exposure and runtime breakage.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/Panels/MarkdownPanel.swift Outdated
<body>
<div id="screen" tabindex="0"></div>
<script type="module" crossorigin="anonymous">
import RFB from "https://novnc.com/noVNC/core/rfb.js";

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Avoid loading the core VNC script from a remote URL. Vendoring/pinning noVNC locally is needed to prevent supply-chain exposure and runtime breakage.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/MarkdownPanel.swift, line 2027:

<comment>Avoid loading the core VNC script from a remote URL. Vendoring/pinning noVNC locally is needed to prevent supply-chain exposure and runtime breakage.</comment>

<file context>
@@ -182,3 +185,2042 @@ final class MarkdownPanel: Panel, ObservableObject {
+        <body>
+          <div id="screen" tabindex="0"></div>
+          <script type="module" crossorigin="anonymous">
+          import RFB from "https://novnc.com/noVNC/core/rfb.js";
+          (() => {
+            const handler = (() => {
</file context>
Fix with Cubic

Comment on lines +1380 to +1390
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
// App launched but couldn't activate — continue in background.
// XCUIElement queries and keyboard input work through the
// accessibility framework regardless of activation state.
return
app.activate()
if app.wait(for: .runningForeground, timeout: 6.0) {
return true
}
}

XCTFail("App failed to start. state=\(app.state.rawValue)")
app.activate()
return app.wait(for: .runningForeground, timeout: 2.0)

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ensureForegroundAfterLaunch waits for foreground before attempting activation, so background launches can incur an unnecessary full timeout per test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxUITests/BrowserPaneNavigationKeybindUITests.swift, line 1380:

<comment>`ensureForegroundAfterLaunch` waits for foreground before attempting activation, so background launches can incur an unnecessary full timeout per test.</comment>

<file context>
@@ -1345,27 +1369,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
+    }
 
+    private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
+        if app.wait(for: .runningForeground, timeout: timeout) {
+            return true
+        }
</file context>
Suggested change
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
// App launched but couldn't activate — continue in background.
// XCUIElement queries and keyboard input work through the
// accessibility framework regardless of activation state.
return
app.activate()
if app.wait(for: .runningForeground, timeout: 6.0) {
return true
}
}
XCTFail("App failed to start. state=\(app.state.rawValue)")
app.activate()
return app.wait(for: .runningForeground, timeout: 2.0)
if app.state == .runningForeground {
return true
}
if app.state == .runningBackground {
app.activate()
if app.wait(for: .runningForeground, timeout: 6.0) {
return true
}
} else if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
app.activate()
return app.wait(for: .runningForeground, timeout: 2.0)
Fix with Cubic

Comment thread Sources/Panels/MarkdownPanelView.swift
},
"Expected Cmd+L to focus omnibar (WebKit not first responder)"
)
_ = waitForGotoSplitMatch(timeout: 2.5) { data in

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Assert that WebKit focus restoration succeeded; otherwise these shortcut tests can pass without exercising the intended first-responder state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxUITests/MenuKeyEquivalentRoutingUITests.swift, line 330:

<comment>Assert that WebKit focus restoration succeeded; otherwise these shortcut tests can pass without exercising the intended first-responder state.</comment>

<file context>
@@ -318,22 +322,14 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase {
-            },
-            "Expected Cmd+L to focus omnibar (WebKit not first responder)"
-        )
+        _ = waitForGotoSplitMatch(timeout: 2.5) { data in
+            data["webViewFocusedAfterAddressBarFocus"] == "false"
+        }
</file context>
Fix with Cubic

Comment thread Sources/Panels/MarkdownPanel.swift Outdated
Comment thread Sources/Panels/MarkdownPanelView.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
Sources/Panels/MarkdownPanel.swift (2)

311-313: ⚠️ Potential issue | 🟠 Major

Add timeout + termination handling for ssh -G alias resolution.

The connect flow awaits VncSSHHostAliasResolver.resolve, but resolveBlocking uses waitUntilExit() with no timeout. If ssh -G hangs, the connect attempt can stall indefinitely.

Suggested fix
 private static func resolveBlocking(host: String) -> String? {
@@
     do {
         try process.run()
     } catch {
         return nil
     }

-    process.waitUntilExit()
+    let exitSignal = DispatchSemaphore(value: 0)
+    process.terminationHandler = { _ in
+        exitSignal.signal()
+    }
+    let waitResult = exitSignal.wait(timeout: .now() + 3.0)
+    if waitResult == .timedOut {
+        process.terminate()
+        _ = exitSignal.wait(timeout: .now() + 0.2)
+        return nil
+    }
     guard process.terminationStatus == 0 else { return nil }
#!/bin/bash
# Verify whether VncSSHHostAliasResolver currently has bounded process lifetime handling.
# Expected: `waitUntilExit` present and no nearby timeout/termination guard => confirms risk.
rg -n -C4 'enum VncSSHHostAliasResolver|Task\.detached|waitUntilExit|terminationHandler|terminate\(' Sources/Panels/MarkdownPanel.swift

Also applies to: 341-342

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 311 - 313,
VncSSHHostAliasResolver.resolveBlocking currently calls Process.waitUntilExit()
with no timeout, so update resolveBlocking (and any similar call sites used by
VncSSHHostAliasResolver.resolve) to enforce a bounded lifetime: start the ssh -G
Process, schedule a timeout (e.g., DispatchQueue or Task.sleep) and if the
process hasn’t exited by deadline call process.terminate(), then if still
running forcefully kill and treat as failure; ensure you capture the termination
status/error and return/fail promptly to the awaiting caller (resolve) instead
of blocking forever; reference functions: VncSSHHostAliasResolver,
resolveBlocking, resolve, and usages of waitUntilExit() and terminationHandler
when locating the code to change.

1361-1370: ⚠️ Potential issue | 🔴 Critical

Remove plaintext keystroke capture from VNC telemetry.

Line 1369 stores raw typed text and Line 1500 exposes it through automation state. This can leak secrets (passwords, tokens) entered in the remote desktop.

Suggested fix
 private struct InputTelemetry {
     var keyDownCount: Int = 0
     var modifiedKeyDownCount: Int = 0
     var textInputCount: Int = 0
     var mouseDownCount: Int = 0
     var mouseUpCount: Int = 0
     var mouseDraggedCount: Int = 0
     var scrollCount: Int = 0
-    var lastText: String = ""
 }

@@
-    var inputLastTextForAutomation: String? {
-        inputTelemetry.lastText.isEmpty ? nil : inputTelemetry.lastText
-    }
+    var inputLastTextForAutomation: String? { nil }

@@
         case .text(let text):
             inputTelemetry.textInputCount += 1
-            if !text.isEmpty {
-                inputTelemetry.lastText = text
-            }
+            _ = text

Also applies to: 1500-1502, 1991-1995

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 1361 - 1370, The
InputTelemetry struct currently captures plaintext user input via the lastText
property and that plaintext is later exposed in automation state; remove this
sensitive data capture by deleting the lastText property from InputTelemetry,
remove any assignments that set lastText (e.g., in input handling code that
updates InputTelemetry.lastText), and remove any code that exposes the plaintext
through automation/state APIs (e.g., the automation state accessor or
serialization that returns lastText). If you still need telemetry about typing,
replace plaintext with non-sensitive alternatives such as counts
(textInputCount), a boolean flag indicating recent typing, or an integer
lastTextLength or a hashed/entropy-only metric; update the code paths that
previously consumed lastText to use the new non-sensitive field instead (e.g.,
where automation state previously read lastText).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/Panels/MarkdownPanel.swift`:
- Around line 311-313: VncSSHHostAliasResolver.resolveBlocking currently calls
Process.waitUntilExit() with no timeout, so update resolveBlocking (and any
similar call sites used by VncSSHHostAliasResolver.resolve) to enforce a bounded
lifetime: start the ssh -G Process, schedule a timeout (e.g., DispatchQueue or
Task.sleep) and if the process hasn’t exited by deadline call
process.terminate(), then if still running forcefully kill and treat as failure;
ensure you capture the termination status/error and return/fail promptly to the
awaiting caller (resolve) instead of blocking forever; reference functions:
VncSSHHostAliasResolver, resolveBlocking, resolve, and usages of waitUntilExit()
and terminationHandler when locating the code to change.
- Around line 1361-1370: The InputTelemetry struct currently captures plaintext
user input via the lastText property and that plaintext is later exposed in
automation state; remove this sensitive data capture by deleting the lastText
property from InputTelemetry, remove any assignments that set lastText (e.g., in
input handling code that updates InputTelemetry.lastText), and remove any code
that exposes the plaintext through automation/state APIs (e.g., the automation
state accessor or serialization that returns lastText). If you still need
telemetry about typing, replace plaintext with non-sensitive alternatives such
as counts (textInputCount), a boolean flag indicating recent typing, or an
integer lastTextLength or a hashed/entropy-only metric; update the code paths
that previously consumed lastText to use the new non-sensitive field instead
(e.g., where automation state previously read lastText).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec3f2afa-5d76-487a-a3ee-e128243a5979

📥 Commits

Reviewing files that changed from the base of the PR and between 5d508dc and 73a48c6.

📒 Files selected for processing (5)
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/Panels/MarkdownPanel.swift
  • Sources/Panels/MarkdownPanelView.swift
  • Sources/SessionPersistence.swift
✅ Files skipped from review due to trivial changes (3)
  • Sources/Panels/MarkdownPanelView.swift
  • Sources/ContentView.swift
  • Resources/Localizable.xcstrings
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/SessionPersistence.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
Sources/Workspace.swift (1)

9251-9330: Consider extracting shared VNC bootstrap logic to reduce drift risk.

newVncSplit(...) and newVncSurface(...) duplicate panel seeding (trim, username/password assignment, subscription install, optional autoconnect). A small helper would make future fixes less error-prone.

♻️ Optional refactor sketch
+    private func makeVncPanel(
+        endpoint: String?,
+        username: String?,
+        password: String?
+    ) -> VncPanel {
+        let trimmedEndpoint = endpoint?.trimmingCharacters(in: .whitespacesAndNewlines)
+        let trimmedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines)
+        let panel = VncPanel(workspaceId: id, endpoint: trimmedEndpoint)
+        if let trimmedUsername, !trimmedUsername.isEmpty { panel.usernameInput = trimmedUsername }
+        if let password { panel.passwordInput = password }
+        panels[panel.id] = panel
+        panelTitles[panel.id] = panel.displayTitle
+        installVncPanelSubscription(panel)
+        return panel
+    }

Also applies to: 9332-9400

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 9251 - 9330, newVncSplit(...) and
newVncSurface(...) both duplicate VncPanel seeding logic (trimming
endpoint/username, assigning usernameInput/passwordInput, storing in
panels/panelTitles/surfaceIdToPanelId, calling installVncPanelSubscription and
handling autoConnect); extract this into a single helper like
seedVncPanel(endpoint:username:password:autoConnect:surfaceId:) that returns the
created VncPanel and performs: trim inputs, init VncPanel(workspaceId: id,
endpoint:), set usernameInput/passwordInput if present, insert into
panels[vncPanel.id], panelTitles[vncPanel.id], surfaceIdToPanelId[newTab.id]
(accept surfaceId param), call installVncPanelSubscription(vncPanel) and
schedule autoConnect via DispatchQueue.main.async, then update newVncSplit and
newVncSurface to call that helper and remove the duplicated code.
Sources/Panels/MarkdownPanelView.swift (1)

472-859: Consider splitting the VNC view out of MarkdownPanelView.swift.

This file now owns markdown UI, VNC UI, shared pointer plumbing, and two representables. Moving VncPanelView into its own file would make the panel boundaries much easier to reason about and review.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanelView.swift` around lines 472 - 859, The
VncPanelView and its two NSViewRepresentable helpers
(VncPanelNativeSessionRepresentable, VncPanelWebViewRepresentable) should be
extracted from the large MarkdownPanelView.swift into a new source file so the
VNC UI is isolated: create a new Swift file containing struct VncPanelView, the
private enum Field, and the two representables, keep the same types/signatures
(VncPanelView, VncPanelNativeSessionRepresentable, VncPanelWebViewRepresentable)
and preserve all referenced state/props (panel, isFocused, isVisibleInUI,
portalPriority, onRequestPanelFocus) and methods (connectOrDisconnect,
focusMostUsefulField, triggerFocusFlashAnimation, focusFlashAnimation) but
import or make accessible any shared symbols used by the VNC code
(FocusFlashPattern, cmuxAccentColor(), MarkdownPointerObserver, CmuxWebView,
etc.)—adjust access control if needed and remove the moved definitions from the
original file, then build to fix any missing imports or visibility issues.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Panels/MarkdownPanelView.swift`:
- Around line 502-514: The connect button can be disabled while a connection is
in progress because isConnectButtonDisabled falls through to endpoint
validation; add an early check for the connecting/cancel path (e.g., if
panel.isConnecting { return false }) at the top of isConnectButtonDisabled so
the UI keeps the visible cancel action enabled (remember connectOrDisconnect()
treats .connecting as the cancel/disconnect case).
- Around line 795-810: The focusMostUsefulField() currently always sets
focusedField = .password after checking username, which ignores
panel.requiredCredentialFields; instead, when panel.isAwaitingCredentials is
true, inspect panel.requiredCredentialFields in order and set focusedField to
the first required credential whose corresponding input (e.g.,
panel.usernameInput, panel.passwordInput) is empty; if none are empty, leave
focus or default to .endpoint. Update focusMostUsefulField() to use
panel.requiredCredentialFields and the inputs to pick the next missing required
field rather than unconditionally assigning .password.

---

Nitpick comments:
In `@Sources/Panels/MarkdownPanelView.swift`:
- Around line 472-859: The VncPanelView and its two NSViewRepresentable helpers
(VncPanelNativeSessionRepresentable, VncPanelWebViewRepresentable) should be
extracted from the large MarkdownPanelView.swift into a new source file so the
VNC UI is isolated: create a new Swift file containing struct VncPanelView, the
private enum Field, and the two representables, keep the same types/signatures
(VncPanelView, VncPanelNativeSessionRepresentable, VncPanelWebViewRepresentable)
and preserve all referenced state/props (panel, isFocused, isVisibleInUI,
portalPriority, onRequestPanelFocus) and methods (connectOrDisconnect,
focusMostUsefulField, triggerFocusFlashAnimation, focusFlashAnimation) but
import or make accessible any shared symbols used by the VNC code
(FocusFlashPattern, cmuxAccentColor(), MarkdownPointerObserver, CmuxWebView,
etc.)—adjust access control if needed and remove the moved definitions from the
original file, then build to fix any missing imports or visibility issues.

In `@Sources/Workspace.swift`:
- Around line 9251-9330: newVncSplit(...) and newVncSurface(...) both duplicate
VncPanel seeding logic (trimming endpoint/username, assigning
usernameInput/passwordInput, storing in panels/panelTitles/surfaceIdToPanelId,
calling installVncPanelSubscription and handling autoConnect); extract this into
a single helper like
seedVncPanel(endpoint:username:password:autoConnect:surfaceId:) that returns the
created VncPanel and performs: trim inputs, init VncPanel(workspaceId: id,
endpoint:), set usernameInput/passwordInput if present, insert into
panels[vncPanel.id], panelTitles[vncPanel.id], surfaceIdToPanelId[newTab.id]
(accept surfaceId param), call installVncPanelSubscription(vncPanel) and
schedule autoConnect via DispatchQueue.main.async, then update newVncSplit and
newVncSurface to call that helper and remove the duplicated code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 64ac9465-4ef8-446c-886d-8993098a47f5

📥 Commits

Reviewing files that changed from the base of the PR and between 73a48c6 and 6caa7ce.

📒 Files selected for processing (6)
  • Resources/Localizable.xcstrings
  • Sources/Panels/MarkdownPanel.swift
  • Sources/Panels/MarkdownPanelView.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
✅ Files skipped from review due to trivial changes (2)
  • Sources/TabManager.swift
  • Resources/Localizable.xcstrings
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/Panels/MarkdownPanel.swift

Comment on lines +502 to +514
private var isConnectButtonDisabled: Bool {
if panel.isConnected {
return false
}
if panel.isAwaitingCredentials {
let usernameReady = !panel.requiredCredentialFields.contains(.username) ||
!panel.usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let passwordReady = !panel.requiredCredentialFields.contains(.password) ||
!panel.passwordInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return !(usernameReady && passwordReady)
}
return panel.endpointInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the button enabled for the .connecting cancel path.

connectOrDisconnect() treats .connecting as a disconnect/cancel action, but isConnectButtonDisabled still falls through to endpoint validation. If the user edits or clears the endpoint mid-connect, the visible cancel button can become disabled until the attempt times out.

Suggested fix
     private var isConnectButtonDisabled: Bool {
         if panel.isConnected {
             return false
         }
         if panel.isAwaitingCredentials {
             let usernameReady = !panel.requiredCredentialFields.contains(.username) ||
                 !panel.usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
             let passwordReady = !panel.requiredCredentialFields.contains(.password) ||
                 !panel.passwordInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
             return !(usernameReady && passwordReady)
         }
+        if panel.connectionState == .connecting {
+            return false
+        }
         return panel.endpointInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanelView.swift` around lines 502 - 514, The connect
button can be disabled while a connection is in progress because
isConnectButtonDisabled falls through to endpoint validation; add an early check
for the connecting/cancel path (e.g., if panel.isConnecting { return false }) at
the top of isConnectButtonDisabled so the UI keeps the visible cancel action
enabled (remember connectOrDisconnect() treats .connecting as the
cancel/disconnect case).

Comment on lines +795 to +810
private func focusMostUsefulField() {
if panel.endpointInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
focusedField = .endpoint
return
}
if panel.isAwaitingCredentials {
if panel.requiredCredentialFields.contains(.username),
panel.usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
focusedField = .username
return
}
focusedField = .password
return
}
focusedField = .endpoint
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Pick the next required credential field, not password unconditionally.

After the username branch, this always focuses .password. For username-only auth challenges, that jumps the caret into an optional field even when no password is required.

Suggested fix
     private func focusMostUsefulField() {
         if panel.endpointInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
             focusedField = .endpoint
             return
         }
         if panel.isAwaitingCredentials {
             if panel.requiredCredentialFields.contains(.username),
                panel.usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                 focusedField = .username
                 return
             }
-            focusedField = .password
+            if panel.requiredCredentialFields.contains(.password) {
+                focusedField = .password
+                return
+            }
+            focusedField = .username
             return
         }
         focusedField = .endpoint
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanelView.swift` around lines 795 - 810, The
focusMostUsefulField() currently always sets focusedField = .password after
checking username, which ignores panel.requiredCredentialFields; instead, when
panel.isAwaitingCredentials is true, inspect panel.requiredCredentialFields in
order and set focusedField to the first required credential whose corresponding
input (e.g., panel.usernameInput, panel.passwordInput) is empty; if none are
empty, leave focus or default to .endpoint. Update focusMostUsefulField() to use
panel.requiredCredentialFields and the inputs to pick the next missing required
field rather than unconditionally assigning .password.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7cb7945532

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1102 to +1104
if usernameTrimmed.isEmpty {
authComponent = ""
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include password-only credentials in VNC URI

When usernameInput is blank but passwordInput is provided, this branch forces authComponent to "", so the password is dropped from the connection address. That makes password-only VNC servers fail authentication (or repeatedly prompt) even though the UI presents username as optional and accepts a password. Build the URI userinfo for password-only inputs as well instead of discarding the password.

Useful? React with 👍 / 👎.

? "[\(trimmedHost)]"
: trimmedHost
let usernameTrimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
let passwordTrimmed = password.trimmingCharacters(in: .whitespacesAndNewlines)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve whitespace in VNC passwords

The password is trimmed before encoding, which silently changes credentials that intentionally start or end with spaces. For such servers, auth will always fail even if the user enters the correct password in the UI. Passwords should be passed verbatim (only host/endpoint fields should be normalized).

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
cmuxUITests/VncPanelInputRoutingUITests.swift (1)

16-64: Add explicit right/other-button coverage before calling this routing complete.

This test only exercises keyboard input plus left click/drag. The new VNC routing work claims right/other mouse-button support too, so that path can regress while this suite still passes. Please add at least one right-click and one otherMouse case, with matching automation counters/assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxUITests/VncPanelInputRoutingUITests.swift` around lines 16 - 64, The test
testVncPanelRoutesKeyboardMultiKeyAndMouseInputInNativeSession only exercises
left click/drag; add an explicit right-click and an other-button click using the
same content coordinates (e.g., perform a right click on center and an
otherMouse click on another coordinate via the content.coordinate(...) helpers)
before the final waitForVncState, and then extend the state-check closure to
assert the new counters (e.g., check
input_right_mouse_down_count/input_right_mouse_up_count and
input_other_mouse_down_count/input_other_mouse_up_count are >= 1) alongside the
existing mouse/key counters so the test fails if right/other button routing
regresses.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Panels/MarkdownPanel.swift`:
- Around line 1594-1599: The snapshot is using runtime connection state
(isConnected || connectionState == .connecting) instead of the configured
auto-connect flag; change sessionSnapshot() to read and persist a stored
autoConnect property on VncPanel (e.g., add/use a Bool property named
autoConnect on VncPanel) and pass that value into
SessionVncPanelSnapshot(autoConnect:), rather than computing it from isConnected
or connectionState; ensure anywhere the panel configuration is created/updated
(constructors or UI bindings) sets VncPanel.autoConnect so the snapshot reflects
the user-configured setting.
- Around line 1652-1658: The async host-resolution check only guards the
resolution path but not later native session callbacks, so stale
VncNativeSessionController `.connected`/`.disconnected` handlers can overwrite
state; update all places that register or handle native session callbacks (e.g.,
where VncNativeSessionController invokes connection state handlers and in
startNativeConnection and any disconnectFromCurrentSession(suppressUpdate:)
callback paths) to capture the current connectAttemptID and early-return if the
stored connectAttemptID != captured attemptID, effectively gating callback work
to the same connection generation (apply the same guard to the other occurrences
noted around the 1866–1868 and 1919–1943 regions).
- Around line 1099-1110: The code currently trims username and password before
encoding (usernameTrimmed/passwordTrimmed) which strips significant
leading/trailing whitespace and breaks valid VNC credentials; remove the
trimming and use the original username and password values when building
authComponent (and when calling Self.encodeUserInfo) so whitespace is preserved,
and likewise update hasCredentialValue(...) to stop trimming input there; also
apply the same change in the other occurrence referenced (around the
hasCredentialValue block at the 1815–1820 area) so whitespace-only or
space-padded credentials can be submitted and authenticated.
- Around line 228-230: The code accepts endpoints like "host:" or "host:abc" by
treating a malformed explicit port as “no port supplied” and defaulting to 5900;
fix this by explicitly validating the portString when
normalizedRemainder.hasPrefix(":") — ensure portString is non-empty and
Int(portString) succeeds before calling resolvedPort, and if parsing fails
return nil (do not fall through to default port). Update the three locations
that use normalizedRemainder, portString and resolvedPort (the occurrences
around the shown snippet plus the blocks at the other reported ranges) to first
check portString.isEmpty, then let portInt = Int(portString), then let port =
resolvedPort(portInt) else return nil.
- Around line 2054-2057: The JS error "line ..." prefix is not localized; update
the two components.append calls in MarkdownPanel (the branch that appends "line
\(line):\(column)" and the one that appends "line \(line)") to use
String(localized:..., defaultValue:...) like the other VNC error strings so the
UI shows localized builds correctly; pick clear localization keys (e.g. a key
for "line {line}:{column}" and one for "line {line}") and interpolate the
line/column values into the localized strings when calling String(localized:...,
defaultValue:...).

---

Nitpick comments:
In `@cmuxUITests/VncPanelInputRoutingUITests.swift`:
- Around line 16-64: The test
testVncPanelRoutesKeyboardMultiKeyAndMouseInputInNativeSession only exercises
left click/drag; add an explicit right-click and an other-button click using the
same content coordinates (e.g., perform a right click on center and an
otherMouse click on another coordinate via the content.coordinate(...) helpers)
before the final waitForVncState, and then extend the state-check closure to
assert the new counters (e.g., check
input_right_mouse_down_count/input_right_mouse_up_count and
input_other_mouse_down_count/input_other_mouse_up_count are >= 1) alongside the
existing mouse/key counters so the test fails if right/other button routing
regresses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad414d0d-e0a1-49d9-b286-c2ea893e6358

📥 Commits

Reviewing files that changed from the base of the PR and between 6caa7ce and 7cb7945.

📒 Files selected for processing (3)
  • Sources/Panels/MarkdownPanel.swift
  • Sources/TerminalController.swift
  • cmuxUITests/VncPanelInputRoutingUITests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/TerminalController.swift

Comment on lines +228 to +230
guard normalizedRemainder.hasPrefix(":") else { return nil }
let portString = String(normalizedRemainder.dropFirst())
guard let port = resolvedPort(Int(portString)) else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject malformed explicit ports instead of defaulting them to 5900.

Int(portString) returns nil for inputs like host: and host:abc, and resolvedPort(_:) currently treats that as “no port supplied”. Those endpoints are accepted and silently connect to the default port instead of surfacing validation error.

💡 Fix sketch
-            let portString = String(normalizedRemainder.dropFirst())
-            guard let port = resolvedPort(Int(portString)) else { return nil }
+            let portString = String(normalizedRemainder.dropFirst())
+            guard !portString.isEmpty,
+                  let port = resolvedPort(Int(portString), wasExplicit: true) else { return nil }

...
-            guard let host = normalizedHost(hostPart),
-                  let port = resolvedPort(Int(portPart)) else {
+            guard let host = normalizedHost(hostPart),
+                  !portPart.isEmpty,
+                  let port = resolvedPort(Int(portPart), wasExplicit: true) else {
                 return nil
             }

...
-    private static func resolvedPort(_ candidate: Int?) -> Int? {
-        guard let candidate else { return defaultPort }
+    private static func resolvedPort(_ candidate: Int?, wasExplicit: Bool = false) -> Int? {
+        guard let candidate else { return wasExplicit ? nil : defaultPort }
         guard (1...65535).contains(candidate) else { return nil }
         return candidate
     }

Also applies to: 243-245, 261-264

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 228 - 230, The code accepts
endpoints like "host:" or "host:abc" by treating a malformed explicit port as
“no port supplied” and defaulting to 5900; fix this by explicitly validating the
portString when normalizedRemainder.hasPrefix(":") — ensure portString is
non-empty and Int(portString) succeeds before calling resolvedPort, and if
parsing fails return nil (do not fall through to default port). Update the three
locations that use normalizedRemainder, portString and resolvedPort (the
occurrences around the shown snippet plus the blocks at the other reported
ranges) to first check portString.isEmpty, then let portInt = Int(portString),
then let port = resolvedPort(portInt) else return nil.

Comment on lines +1099 to +1110
let usernameTrimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
let passwordTrimmed = password.trimmingCharacters(in: .whitespacesAndNewlines)
let authComponent: String
if usernameTrimmed.isEmpty {
authComponent = ""
} else {
let encodedUser = Self.encodeUserInfo(usernameTrimmed)
if passwordTrimmed.isEmpty {
authComponent = "\(encodedUser)@"
} else {
let encodedPassword = Self.encodeUserInfo(passwordTrimmed)
authComponent = "\(encodedUser):\(encodedPassword)@"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't trim VNC credentials before using them.

Leading/trailing whitespace is significant for passwords. This mutates " secret " into "secret", so valid credentials can never authenticate. The same trimming in hasCredentialValue(...) also makes whitespace-only credentials impossible to submit.

🔐 Fix sketch
-        let usernameTrimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
-        let passwordTrimmed = password.trimmingCharacters(in: .whitespacesAndNewlines)
+        let usernameValue = username
+        let passwordValue = password
         let authComponent: String
-        if usernameTrimmed.isEmpty {
+        if usernameValue.isEmpty {
             authComponent = ""
         } else {
-            let encodedUser = Self.encodeUserInfo(usernameTrimmed)
-            if passwordTrimmed.isEmpty {
+            let encodedUser = Self.encodeUserInfo(usernameValue)
+            if passwordValue.isEmpty {
                 authComponent = "\(encodedUser)@"
             } else {
-                let encodedPassword = Self.encodeUserInfo(passwordTrimmed)
+                let encodedPassword = Self.encodeUserInfo(passwordValue)
                 authComponent = "\(encodedUser):\(encodedPassword)@"
             }
         }

...
-            return !usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+            return !usernameInput.isEmpty
...
-            return !passwordInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+            return !passwordInput.isEmpty

Also applies to: 1815-1820

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 1099 - 1110, The code
currently trims username and password before encoding
(usernameTrimmed/passwordTrimmed) which strips significant leading/trailing
whitespace and breaks valid VNC credentials; remove the trimming and use the
original username and password values when building authComponent (and when
calling Self.encodeUserInfo) so whitespace is preserved, and likewise update
hasCredentialValue(...) to stop trimming input there; also apply the same change
in the other occurrence referenced (around the hasCredentialValue block at the
1815–1820 area) so whitespace-only or space-padded credentials can be submitted
and authenticated.

Comment on lines +1594 to +1599
func sessionSnapshot() -> SessionVncPanelSnapshot {
SessionVncPanelSnapshot(
endpointInput: endpointInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : endpointInput,
usernameInput: usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : usernameInput,
autoConnect: isConnected || connectionState == .connecting
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist the configured auto-connect flag, not the current connection state.

This writes snapshot.autoConnect from isConnected || connectionState == .connecting, so a one-off manual connect becomes sticky across restarts, while a panel that was configured to auto-connect stops reconnecting if it happens to be disconnected/error at snapshot time. autoConnect needs to be stored on VncPanel and snapshotted directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 1594 - 1599, The snapshot is
using runtime connection state (isConnected || connectionState == .connecting)
instead of the configured auto-connect flag; change sessionSnapshot() to read
and persist a stored autoConnect property on VncPanel (e.g., add/use a Bool
property named autoConnect on VncPanel) and pass that value into
SessionVncPanelSnapshot(autoConnect:), rather than computing it from isConnected
or connectionState; ensure anywhere the panel configuration is created/updated
(constructors or UI bindings) sets VncPanel.autoConnect so the snapshot reflects
the user-configured setting.

Comment on lines +1652 to +1658
Task { [weak self] in
let resolvedHost = await VncSSHHostAliasResolver.resolve(host: target.host) ?? target.host
await MainActor.run { [weak self] in
guard let self else { return }
guard self.connectAttemptID == attemptID else { return }
guard self.activeTarget == target else { return }
self.startNativeConnection(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate native session callbacks to the current connect generation.

connectAttemptID only protects the async host-resolution task. The reused VncNativeSessionController keeps polling after disconnectFromCurrentSession(suppressUpdate: true), so stale .disconnected/.connected callbacks from the previous session can still overwrite the new panel state and even record the wrong target as “recent” before the next startNativeConnection(...) is actually running.

Also applies to: 1866-1868, 1919-1943

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 1652 - 1658, The async
host-resolution check only guards the resolution path but not later native
session callbacks, so stale VncNativeSessionController
`.connected`/`.disconnected` handlers can overwrite state; update all places
that register or handle native session callbacks (e.g., where
VncNativeSessionController invokes connection state handlers and in
startNativeConnection and any disconnectFromCurrentSession(suppressUpdate:)
callback paths) to capture the current connectAttemptID and early-return if the
stored connectAttemptID != captured attemptID, effectively gating callback work
to the same connection generation (apply the same guard to the other occurrences
noted around the 1866–1868 and 1919–1943 regions).

Comment on lines +2054 to +2057
if let line, let column {
components.append("line \(line):\(column)")
} else if let line {
components.append("line \(line)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Localize this JS error prefix before surfacing it in the panel.

"line \(line)" is part of lastErrorDetail, so localized builds will still show English here. Please route it through String(localized:..., defaultValue:...) like the other VNC error strings.

As per coding guidelines, "All user-facing strings must be localized. Use String(localized: "key.name", defaultValue: "English text") for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 2054 - 2057, The JS error
"line ..." prefix is not localized; update the two components.append calls in
MarkdownPanel (the branch that appends "line \(line):\(column)" and the one that
appends "line \(line)") to use String(localized:..., defaultValue:...) like the
other VNC error strings so the UI shows localized builds correctly; pick clear
localization keys (e.g. a key for "line {line}:{column}" and one for "line
{line}") and interpolate the line/column values into the localized strings when
calling String(localized:..., defaultValue:...).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Panels/MarkdownPanel.swift">

<violation number="1" location="Sources/Panels/MarkdownPanel.swift:1213">
P2: `configureInteractiveControlMode()` is being called on every connected-state poll (10x/sec). Gate it to state transitions so control/input configuration is not reapplied continuously.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

setObjectBool(false, on: sessionObject, selectorName: "setHilightCursor:")
setBool(true, on: sessionObject, selectorName: "setViewerCursorVisible:")
setBool(true, on: sessionObject, selectorName: "setShouldShowCursorForUnknownCursorState:")
configureInteractiveControlMode()

@cubic-dev-ai cubic-dev-ai Bot Apr 14, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: configureInteractiveControlMode() is being called on every connected-state poll (10x/sec). Gate it to state transitions so control/input configuration is not reapplied continuously.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/MarkdownPanel.swift, line 1213:

<comment>`configureInteractiveControlMode()` is being called on every connected-state poll (10x/sec). Gate it to state transitions so control/input configuration is not reapplied continuously.</comment>

<file context>
@@ -1042,7 +1210,7 @@ private final class VncNativeSessionController: NSObject {
         setBool(true, on: sessionObject, selectorName: "setViewerCursorVisible:")
         setBool(true, on: sessionObject, selectorName: "setShouldShowCursorForUnknownCursorState:")
-        setBool(true, on: sessionObject, selectorName: "setKeyboardFocusEnabled:")
+        configureInteractiveControlMode()
         setBool(true, on: sessionObject, selectorName: "setShouldSharePasteboard:")
         setBool(true, on: sessionObject, selectorName: "setShouldAllowSendPasteboard:")
</file context>
Suggested change
configureInteractiveControlMode()
if emittedState != .connected {
configureInteractiveControlMode()
}
Fix with Cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (6)
Sources/Panels/MarkdownPanel.swift (6)

1276-1292: ⚠️ Potential issue | 🟠 Major

Don't trim VNC credentials before using them.

Leading/trailing whitespace is significant for passwords. Trimming " secret " into "secret" causes valid credentials to fail authentication.

🔐 Fix sketch
 let hostComponent = trimmedHost.contains(":")
     ? "[\(trimmedHost)]"
     : trimmedHost
-let usernameTrimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
-let passwordTrimmed = password.trimmingCharacters(in: .whitespacesAndNewlines)
 let authComponent: String
-if usernameTrimmed.isEmpty {
+if username.isEmpty {
     authComponent = ""
 } else {
-    let encodedUser = Self.encodeUserInfo(usernameTrimmed)
-    if passwordTrimmed.isEmpty {
+    let encodedUser = Self.encodeUserInfo(username)
+    if password.isEmpty {
         authComponent = "\(encodedUser)@"
     } else {
-        let encodedPassword = Self.encodeUserInfo(passwordTrimmed)
+        let encodedPassword = Self.encodeUserInfo(password)
         authComponent = "\(encodedUser):\(encodedPassword)@"
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 1276 - 1292, The code trims
VNC credentials (usernameTrimmed/passwordTrimmed) which can remove meaningful
whitespace; revert to using the original username and password values when
building authComponent instead of usernameTrimmed/passwordTrimmed. Replace
references to usernameTrimmed and passwordTrimmed in the authComponent
construction with the raw username and password variables, still passing them
through Self.encodeUserInfo when creating encodedUser/encodedPassword so
encoding is preserved but trimming is removed.

1772-1778: ⚠️ Potential issue | 🟠 Major

Persist the configured auto-connect flag, not the current connection state.

autoConnect: isConnected || connectionState == .connecting writes runtime state instead of user intent. A one-off manual connect becomes sticky across restarts, while a panel configured to auto-connect won't reconnect if it happens to be disconnected at snapshot time.

💡 Fix sketch

Add a stored autoConnect: Bool property to VncPanel, set it when the user enables auto-connect in the UI (or during init from restore), and snapshot that value:

+    private var autoConnect: Bool = false
+
     func sessionSnapshot() -> SessionVncPanelSnapshot {
         SessionVncPanelSnapshot(
             endpointInput: endpointInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : endpointInput,
             usernameInput: usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : usernameInput,
-            autoConnect: isConnected || connectionState == .connecting
+            autoConnect: autoConnect
         )
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 1772 - 1778, The snapshot is
recording runtime connection state instead of the user's intent; add a stored
Bool property (e.g. autoConnect) to VncPanel, ensure the UI toggles that
property when the user enables/disables auto-connect and that the init/restore
path populates it, then change sessionSnapshot() to pass that stored autoConnect
value into SessionVncPanelSnapshot (leave endpointInput and usernameInput
handling as-is); also update any restore/serialize logic that reconstructs the
panel to read/write the new autoConnect field.

2233-2237: ⚠️ Potential issue | 🟡 Minor

Localize the JS error line prefix before surfacing it in the panel.

"line \(line)" is part of lastErrorDetail shown in the UI. Use String(localized:..., defaultValue:...) like other VNC error strings.

💡 Fix sketch
 if let line, let column {
-    components.append("line \(line):\(column)")
+    components.append(
+        String(localized: "vnc.error.jsLineColumn", defaultValue: "line \(line):\(column)")
+    )
 } else if let line {
-    components.append("line \(line)")
+    components.append(
+        String(localized: "vnc.error.jsLine", defaultValue: "line \(line)")
+    )
 }

As per coding guidelines, "All user-facing strings must be localized. Use String(localized: "key.name", defaultValue: "English text") for every string shown in the UI."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 2233 - 2237, The UI shows JS
error location strings built in MarkdownPanel.swift (the components.append calls
that produce "line \(line):\(column)" and "line \(line)") and these must be
localized before being added to lastErrorDetail; replace the hardcoded literals
with localized strings using String(localized: "error.lineAndColumn" /
"error.line", defaultValue: "line %d:%d" / "line %d") and format them with the
line/column values when building components so the final lastErrorDetail remains
fully localized.

1994-2001: ⚠️ Potential issue | 🟡 Minor

Don't trim credentials when checking for presence.

hasCredentialValue trims whitespace, making whitespace-only credentials (e.g., " ") impossible to submit. Use .isEmpty directly on the raw input.

🔐 Fix sketch
 private func hasCredentialValue(for field: VncCredentialField) -> Bool {
     switch field {
     case .username:
-        return !usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        return !usernameInput.isEmpty
     case .password:
-        return !passwordInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        return !passwordInput.isEmpty
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 1994 - 2001, The
hasCredentialValue(for:) helper is incorrectly trimming inputs so
whitespace-only credentials are treated as empty; update hasCredentialValue
(used with VncCredentialField cases .username and .password) to check the raw
usernameInput and passwordInput using .isEmpty (i.e., return
!usernameInput.isEmpty / !passwordInput.isEmpty) instead of
trimmingCharacters(in: .whitespacesAndNewlines) so whitespace-only values are
considered present.

2098-2123: ⚠️ Potential issue | 🟠 Major

Gate native session callbacks to the current connect generation.

handleNativeSessionState doesn't check connectAttemptID, so callbacks from a previous session (after disconnect(suppressUpdate: true)) can still overwrite the new panel state. Capture the attempt ID when setting up the callback and early-return if it no longer matches.

💡 Fix sketch

One approach: capture connectAttemptID in the callback setup and guard:

+    private var activeNativeSessionAttemptID: UUID?
+
     private func startNativeConnection(...) {
         guard let nativeSessionController else { ... }
+        let attemptID = connectAttemptID
+        activeNativeSessionAttemptID = attemptID
         nativeSessionController.connect(...)
     }

     private func handleNativeSessionState(_ state: VncPanelConnectionState, detail: String?) {
+        guard activeNativeSessionAttemptID == connectAttemptID else { return }
         switch state {
         ...
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 2098 - 2123,
handleNativeSessionState currently applies session callbacks regardless of which
connect attempt they belong to, allowing stale callbacks to overwrite a newer
panel state; when you register the native session callback capture the current
connectAttemptID (the value updated by connect/disconnect and used by
disconnect(suppressUpdate: true)) into a local constant and early-return from
the callback if the panel's current connectAttemptID no longer matches that
captured value, so modify the callback registration sites to check the
capturedAttemptID before calling handleNativeSessionState (or have
handleNativeSessionState accept the attemptID and guard) to ensure only the
latest connection generation updates connectionState, lastErrorDetail and
requiredCredentialFields.

228-231: ⚠️ Potential issue | 🟡 Minor

Reject malformed explicit ports instead of defaulting them to 5900.

When normalizedRemainder.hasPrefix(":") is true but the port string is empty or non-numeric (e.g., host: or host:abc), Int(portString) returns nil and resolvedPort(nil) returns defaultPort. This silently accepts malformed input instead of surfacing a validation error.

💡 Fix sketch
 guard normalizedRemainder.hasPrefix(":") else { return nil }
 let portString = String(normalizedRemainder.dropFirst())
-guard let port = resolvedPort(Int(portString)) else { return nil }
+guard !portString.isEmpty,
+      let parsedPort = Int(portString),
+      let port = resolvedPort(parsedPort) else { return nil }
 return VncPanelTarget(host: host, port: port)

Apply similar fix to the colonCount == 1 branch at lines 242-245.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/MarkdownPanel.swift` around lines 228 - 231, The code
currently treats malformed explicit ports like "host:" or "host:abc" as valid by
passing nil into resolvedPort and getting the default 5900; change the logic in
the normalizedRemainder.hasPrefix(":") branch (and the colonCount == 1 branch)
to explicitly validate the port string: require that portString is non-empty and
Int(portString) succeeds, return nil on failure, then call resolvedPort with the
parsed Int and construct VncPanelTarget(host: host, port: port); update the
branches that reference normalizedRemainder, portString, resolvedPort,
colonCount and VncPanelTarget accordingly.
🧹 Nitpick comments (1)
cmuxTests/TerminalAndGhosttyTests.swift (1)

4246-4302: Consider adding right/other mouse forwarding assertions in this suite.

VncNativeSessionHostView also routes rightMouseDown and otherMouseDown; adding direct unit assertions here would keep coverage complete for native multi-button routing.

♻️ Suggested patch
 private final class ProbeSessionView: NSView {
     var allowsFirstResponder = true
     var keyDownCount = 0
     var mouseDownCount = 0
+    var rightMouseDownCount = 0
+    var otherMouseDownCount = 0
     var performKeyEquivalentCount = 0
     var consumeKeyEquivalent = false
@@
     override func mouseDown(with event: NSEvent) {
         _ = event
         mouseDownCount += 1
     }
+
+    override func rightMouseDown(with event: NSEvent) {
+        _ = event
+        rightMouseDownCount += 1
+    }
+
+    override func otherMouseDown(with event: NSEvent) {
+        _ = event
+        otherMouseDownCount += 1
+    }
@@
         hostView.keyDown(with: makeKeyEvent(characters: "a", keyCode: 0, window: window))
         hostView.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 40), window: window))
+        hostView.rightMouseDown(with: makeMouseEvent(type: .rightMouseDown, location: NSPoint(x: 40, y: 40), window: window))
+        hostView.otherMouseDown(with: makeMouseEvent(type: .otherMouseDown, location: NSPoint(x: 40, y: 40), window: window))
@@
         XCTAssertEqual(sessionView.keyDownCount, 1)
         XCTAssertEqual(sessionView.mouseDownCount, 1)
+        XCTAssertEqual(sessionView.rightMouseDownCount, 1)
+        XCTAssertEqual(sessionView.otherMouseDownCount, 1)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/TerminalAndGhosttyTests.swift` around lines 4246 - 4302, Add
assertions and support for right/other mouse forwarding by extending
ProbeSessionView to track rightMouseDownCount and otherMouseDownCount and
overriding rightMouseDown(with:) and otherMouseDown(with:) to increment them;
then in
testHostViewForwardsMouseAndKeyboardWhenSessionCannotBecomeFirstResponder send
right and other mouse events via hostView (using makeMouseEvent with types
.rightMouseDown and .otherMouseDown) and assert the corresponding
sessionView.rightMouseDownCount and sessionView.otherMouseDownCount are 1,
similar to the existing mouseDown/keyDown checks; update references to
ProbeSessionView and
testHostViewForwardsMouseAndKeyboardWhenSessionCannotBecomeFirstResponder
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/Panels/MarkdownPanel.swift`:
- Around line 1276-1292: The code trims VNC credentials
(usernameTrimmed/passwordTrimmed) which can remove meaningful whitespace; revert
to using the original username and password values when building authComponent
instead of usernameTrimmed/passwordTrimmed. Replace references to
usernameTrimmed and passwordTrimmed in the authComponent construction with the
raw username and password variables, still passing them through
Self.encodeUserInfo when creating encodedUser/encodedPassword so encoding is
preserved but trimming is removed.
- Around line 1772-1778: The snapshot is recording runtime connection state
instead of the user's intent; add a stored Bool property (e.g. autoConnect) to
VncPanel, ensure the UI toggles that property when the user enables/disables
auto-connect and that the init/restore path populates it, then change
sessionSnapshot() to pass that stored autoConnect value into
SessionVncPanelSnapshot (leave endpointInput and usernameInput handling as-is);
also update any restore/serialize logic that reconstructs the panel to
read/write the new autoConnect field.
- Around line 2233-2237: The UI shows JS error location strings built in
MarkdownPanel.swift (the components.append calls that produce "line
\(line):\(column)" and "line \(line)") and these must be localized before being
added to lastErrorDetail; replace the hardcoded literals with localized strings
using String(localized: "error.lineAndColumn" / "error.line", defaultValue:
"line %d:%d" / "line %d") and format them with the line/column values when
building components so the final lastErrorDetail remains fully localized.
- Around line 1994-2001: The hasCredentialValue(for:) helper is incorrectly
trimming inputs so whitespace-only credentials are treated as empty; update
hasCredentialValue (used with VncCredentialField cases .username and .password)
to check the raw usernameInput and passwordInput using .isEmpty (i.e., return
!usernameInput.isEmpty / !passwordInput.isEmpty) instead of
trimmingCharacters(in: .whitespacesAndNewlines) so whitespace-only values are
considered present.
- Around line 2098-2123: handleNativeSessionState currently applies session
callbacks regardless of which connect attempt they belong to, allowing stale
callbacks to overwrite a newer panel state; when you register the native session
callback capture the current connectAttemptID (the value updated by
connect/disconnect and used by disconnect(suppressUpdate: true)) into a local
constant and early-return from the callback if the panel's current
connectAttemptID no longer matches that captured value, so modify the callback
registration sites to check the capturedAttemptID before calling
handleNativeSessionState (or have handleNativeSessionState accept the attemptID
and guard) to ensure only the latest connection generation updates
connectionState, lastErrorDetail and requiredCredentialFields.
- Around line 228-231: The code currently treats malformed explicit ports like
"host:" or "host:abc" as valid by passing nil into resolvedPort and getting the
default 5900; change the logic in the normalizedRemainder.hasPrefix(":") branch
(and the colonCount == 1 branch) to explicitly validate the port string: require
that portString is non-empty and Int(portString) succeeds, return nil on
failure, then call resolvedPort with the parsed Int and construct
VncPanelTarget(host: host, port: port); update the branches that reference
normalizedRemainder, portString, resolvedPort, colonCount and VncPanelTarget
accordingly.

---

Nitpick comments:
In `@cmuxTests/TerminalAndGhosttyTests.swift`:
- Around line 4246-4302: Add assertions and support for right/other mouse
forwarding by extending ProbeSessionView to track rightMouseDownCount and
otherMouseDownCount and overriding rightMouseDown(with:) and
otherMouseDown(with:) to increment them; then in
testHostViewForwardsMouseAndKeyboardWhenSessionCannotBecomeFirstResponder send
right and other mouse events via hostView (using makeMouseEvent with types
.rightMouseDown and .otherMouseDown) and assert the corresponding
sessionView.rightMouseDownCount and sessionView.otherMouseDownCount are 1,
similar to the existing mouseDown/keyDown checks; update references to
ProbeSessionView and
testHostViewForwardsMouseAndKeyboardWhenSessionCannotBecomeFirstResponder
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4f338c3c-a705-4b22-907c-247453d5d115

📥 Commits

Reviewing files that changed from the base of the PR and between 7cb7945 and 7a123f4.

📒 Files selected for processing (2)
  • Sources/Panels/MarkdownPanel.swift
  • cmuxTests/TerminalAndGhosttyTests.swift

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 7a123f48 Deployed Apr 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants